Skip to content

feat: allowed-origins credential boundary — don't attach credentials to third-party 401s#22

Draft
jeswr wants to merge 1 commit into
mainfrom
feat/allowed-origins-credential-boundary
Draft

feat: allowed-origins credential boundary — don't attach credentials to third-party 401s#22
jeswr wants to merge 1 commit into
mainfrom
feat/allowed-origins-credential-boundary

Conversation

@jeswr

@jeswr jeswr commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

The problem

ReactiveFetchManager.registerGlobally() replaces globalThis.fetch, so every fetch the page makes flows through the reactive layer — not just requests to the user's pod, but also requests to third-party origins: a CDN, an image host, an analytics beacon, or a URL embedded in fetched data (a foaf:img, an <img> a profile links to, an rdfs:seeAlso).

Today the manager (and ReactiveAuthenticationClient) upgrade any 401:

const response = await this.#globalFetch.call(undefined, request.clone())
if (response.status !== 401) return response
const provider = await this.#findProvider(request)   // DPoPTokenProvider.matches() → true
const upgraded = await provider.upgrade(request)     // runs the auth flow…
return this.#globalFetch.call(undefined, upgraded)   // …and retries WITH credentials

So an origin the app never intended to authenticate to can answer 401 and receive a retry carrying the user's Solid credentials — the Authorization access token (a bearer credential carrying the user's WebID/identity) minted for that origin's URL. That is a credential/identity-leak vector: a hostile 401 harvests the user's token.

The fix

An opt-in allowedOrigins credential boundary — the allow-list of origins the consumer trusts with credentials (typically their pod/storage origins). A 401 from an out-of-boundary origin is now returned untouched: no provider is consulted, no upgrade runs, no credentials are attached.

import { CredentialBoundary, ReactiveFetchManager } from "@solid/reactive-authentication"

const boundary = new CredentialBoundary(["https://alice.pod.example"])
const manager = new ReactiveFetchManager([provider], { allowedOrigins: boundary })
manager.registerGlobally()

// widen in place later (e.g. a newly-discovered storage the user can reach) —
// no session disruption, no token re-grant:
boundary.add("https://shared.pod.example")
  • allowedOrigins accepts a CredentialBoundary (hold the ref to widen it later) or any iterable of origins / origin-predicates.
  • The boundary compares canonical origins (new URL(url).origin), not URL substrings — so https://pod.example.attacker.test and http://pod.example are correctly not trusted (a look-alike host or downgraded scheme can't slip past).
  • Applies to both ReactiveFetchManager and ReactiveAuthenticationClient (the latter backs the service-worker path).

Compatibility

Fully backwards-compatible. Omitting allowedOrigins preserves the existing "upgrade every 401" behaviour and logs a one-time warning recommending the boundary. No breaking change to the constructor (the options arg is optional).

Suite evidence

This is the allowed-origins credential-boundary model the @jeswr app suite built into its shared login core after apps that patch global fetch were observed sending pod credentials onto foreign origins reached transitively from fetched RDF. The suite treats the origins-that-may-receive-credentials as an explicit, widenable trust boundary; this PR is that model adapted to reactive-authentication's API.

Verification

  • npx tsc — clean (the CI check).
  • Added test/CredentialBoundary.test.ts (11 cases, npm test / vitest): out-of-boundary 401 is passed through with provider.upgrade never called (no token minted); in-boundary 401 upgrades; add() reArm widens a live boundary; canonical-origin comparison rejects look-alike hosts; the no-boundary legacy path still upgrades everything and warns exactly once. (Note: this repo's CI currently runs tsc only, as with the other draft PRs that added vitest; the tests run locally via npm test.)

🤖 PSS agent — @jeswr's agent for the Solid app suite; proposing hardening the suite built in @jeswr/solid-auth-core. Draft for review — happy to adjust the API shape (e.g. auto-widening on a successful upgrade, or making the boundary required in a future major).

…to third-party 401s

When the reactive layer patches global `fetch`, every request the page makes —
including to third-party origins (a CDN, image host, analytics beacon, or a URL
embedded in fetched data) — flows through it. Today ANY origin that answers 401
triggers a provider `upgrade` and receives a retry carrying the user's Solid
credentials (the `Authorization` access token minted for that origin's URL).
That hands the user's identity/credential to origins they never chose to trust.

Add an opt-in `allowedOrigins` credential boundary on `ReactiveFetchManager`
and `ReactiveAuthenticationClient`: a 401 from an out-of-boundary origin is
returned untouched — no provider consulted, no credentials attached. The
`CredentialBoundary` compares canonical origins (not URL substrings, so a
look-alike host cannot slip past) and can be widened in place after
construction without disrupting a live session.

Backwards-compatible: omitting `allowedOrigins` preserves the previous
"upgrade every 401" behaviour and logs a one-time warning recommending it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant